Capture MCP tool calls in PostHog - #128
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d9b21ea. Configure here.
dcruzeneil2
left a comment
There was a problem hiding this comment.
Thanks for putting this together. The overall direction is strong, and there are several thoughtful decisions here, particularly disabling context capture, pinning the pre-1.0 SDK version, and removing tool arguments and responses across all tools. The session propagation approach is also a sensible solution for a stateless deployment.
I reviewed the implementation and traced the relevant behavior through the pinned versions of @posthog/mcp and posthog-node. I found a few privacy issues that I think should be addressed before approval, followed by some non-blocking correctness and analytics considerations.
Blockers
Application-wide exception capture is enabled
enableExceptionAutocapture: true is configured on the posthog-node client. In this location, it installs process-level handlers for uncaught exceptions and unhandled promise rejections.
This is not limited to MCP tool calls. It can capture failures from unrelated code running in the same server process. These events also bypass the MCP-specific beforeSend filtering, so their messages and stack traces are not protected by the payload-removal logic in this PR.
This option should be removed from the PostHog client configuration.
Failed tool responses can still leak through error properties
Removing $mcp_parameters and $mcp_response is a good safeguard. However, @posthog/mcp processes failed tool results separately.
The SDK can extract text from a failed result and send it as $mcp_error_message. It can also create a separate $exception event containing the same message. Neither is removed by the current filter.
This means free-form error text can still leave the server even though the original response is removed. Error messages may contain upstream response details or user-provided values.
To preserve a metadata-only boundary, free-form error messages and companion exception events should not be captured. A safe categorical field such as the error type can still be retained.
The payload filter permits unknown properties by default
The current filter removes two known properties and sends everything else:
delete properties[PostHogMCPAnalyticsProperty.Parameters];
delete properties[PostHogMCPAnalyticsProperty.Response];This works with the currently pinned SDK schema. However, if a future SDK version renames one of these properties or introduces another payload-bearing property, that data would be sent automatically.
Because this integration is intended to send metadata only, it should use an explicit allow-list of approved metadata properties. New or unknown properties should be discarded by default.
Non-blockers
Analytics identity and grouping
The selected distinct_id may fragment user profiles
The integration uses the Clerk subject as the PostHog distinct_id.
That is only correct if the Clerk subject is the canonical identity used by every other producer writing to the same PostHog project. If another producer identifies the same person using a different stable ID, PostHog will create separate profiles for the same person.
This would make cross-product user journeys and user-level adoption analysis unreliable. The selected distinct_id should be checked against the identity convention of the target PostHog project before merging.
API-key traffic lacks a stable customer identity
The identity callback returns no identity for API-key requests. Those events may therefore be associated only with an analytics session rather than a stable customer identity.
Tool-level reporting will still work, but usage across multiple sessions cannot be reliably attributed to the same customer.
Organization grouping is not included
The integration does not attach an organization group. This limits the ability to report on MCP adoption and usage at the customer or organization level.
The intended identity and grouping behavior should be documented, even if implementation is left as follow-up work.
Session and transport correctness
Legacy SSE sessions may be split
The custom session ID is minted for any POST containing an initialize request. This includes initialization requests sent through the legacy /message transport.
Legacy SSE clients do not necessarily replay the new session header. The initialize event may therefore use the newly minted ID while later tool events use the existing SSE transport session ID.
This would place initialization in one analytics session and subsequent tool calls in another. Custom session minting should be limited to the streamable HTTP transport.
Protocol version is not preserved
The session token stores the session ID, client name, and client version, but not the protocol version from the initialize request.
Later stateless requests rely on this token for session information, so their analytics events cannot consistently include the MCP protocol version.
Request reconstruction has no fallback
The initialize request is reconstructed to inject the session header. The normal path should work, but reconstruction does not preserve every part of the original request, such as its abort signal.
There is also no fallback if reconstruction fails. Since initialization is required before a client can use the server, an analytics-related failure on this path could prevent the MCP session from starting.
Performance
Large payloads are processed before they are removed
The SDK sanitizes, truncates, and measures tool arguments and responses before beforeSend removes them.
Large tool results may therefore consume CPU and memory even though they are never transmitted. Avoiding this work likely requires an upstream SDK option that disables payload capture earlier in the pipeline.
Recommendation
This is a useful addition, and the underlying approach looks solid. I would request changes for the three privacy blockers:
- Remove application-wide exception autocapture.
- Prevent free-form tool error messages and companion exception events from being sent.
- Replace the property deny-list with an explicit metadata allow-list.
I would also confirm the distinct_id strategy before merging because an identity mismatch would make user-level analytics misleading and difficult to repair later.
Once the privacy boundaries are tightened, I would be comfortable approving. The remaining session, transport, grouping, and performance findings can reasonably be handled as follow-up work.
Removes process-level exception autocapture, drops the $exception sibling and free-form error text a failed tool call produced, and replaces the payload deny-list with an allow-list of the properties this integration intends to send. Also stops identifying on the Clerk subject, which is not the identity other producers in these PostHog projects use, carries the negotiated protocol version in the session token, limits session minting to the streamable HTTP transport, and forwards the abort signal when reconstructing the initialize request.
|
Thanks — this was a careful read, and all three blockers were real. Traced each one through the pinned Blockers1. Application-wide exception autocapture — removed. Confirmed in 2. Free-form error text — no longer sent, and this had already happened. Two channels, both confirmed in the SDK: Not theoretical: the staging project already holds 7 3. Deny-list → allow-list — done. Verified end to end against the staging project: a 4-event session (initialize, tools/list, one successful and one failed tool call) carries only the allow-listed keys plus the ones Non-blockersIdentity — you were right to flag it, and the answer is that the Clerk subject is wrong. Checked against the target project rather than assuming: over 7 days, 0 events use a Clerk-style The MCP server can't resolve the Kernel user id today (nothing in Organization grouping — follow-up, and more standard than "nice to have": 31.5M of 33.2M production events in the last 7 days carry Legacy SSE session splitting — fixed. Minting is now gated to the streamable HTTP transport ( Protocol version — fixed. Request reconstruction — abort signal now forwarded via Payload processing cost — upstream, and filed. Confirmed the ordering you describe: |
dcruzeneil2
left a comment
There was a problem hiding this comment.
Thanks for the detailed response and for addressing the findings so thoroughly. I re-reviewed the latest commit and confirmed that all three blockers are resolved:
- Process-wide exception autocapture has been removed.
- Free-form tool error messages and companion
$exceptionevents are no longer sent. - Event properties now use an explicit metadata allow-list.
The identity change is also a safe choice for now. Keeping events session-scoped avoids creating incorrect person profiles while leaving proper user and organization attribution as additive follow-up work.
The legacy SSE session handling, protocol version propagation, and abort signal forwarding have also been addressed. I agree that payload preprocessing belongs upstream and that request reconstruction fallback does not need to block this PR.
Everything needed for this PR is addressed. Approving.

Summary
Instruments the MCP server with PostHog MCP analytics (
@posthog/mcp, pinned to0.10.1) so every inboundtools/call,tools/list, andinitializeis captured as a$mcp_*event — tool name, latency, error state, client name/version, session.What that buys us: per-tool call volume, error rate and latency percentiles, which clients connect, and which advertised tools never get called. Today we ship tool changes blind — kernel API traffic tagged
X-Source: mcp-servershows endpoints, not tools.src/lib/mcp/analytics.ts— module-scopeposthog-nodeclient plusinstrument()wiring.identifyattributes events to the Clerk user id when the request carries a JWT; API-key requests stay session-scoped rather than getting a made-up identity.src/app/[transport]/route.ts— instrument inside thecreateMcpHandlercallback, mint a session id on the handshake (below), and drain the queue withafter()so the flush runs once the response is already on the wire. Capture adds no latency to a tool call..env.example—POSTHOG_PROJECT_TOKENandPOSTHOG_HOST. With no token set the whole thing is a no-op, so deploys and local dev without PostHog credentials are unaffected (dev logs a one-time error so the miss isn't silent).POSTHOG_PROJECT_TOKENandPOSTHOG_HOSTare set on the Vercel project (production → prod PostHog project, preview → staging).Sessions
mcp-handlerruns the streamable-HTTP transport statelessly and answers over SSE, so it never issues anMcp-Session-Id. Left alone that means one PostHog session per HTTP request, and$mcp_client_name/$mcp_client_version— only sent atinitialize— missing from every later event.mintMcpSessionIdhandles it the way the SDK documents for SSE: on aninitializePOST it mints a session token carrying a fresh session id plus the client name and version, injects it on the inbound request (so the handshake event lands in that session too) and echoes it back on the response. Clients replay the header, and any instance decodes the same values out of it — no session store, no sticky routing. The stateless transport ignores an incoming session id (validateSessionshort-circuits whensessionIdGeneratoris undefined), so this can't produce 400/404s.Mcp-Session-Idis added to the CORS allow/expose lists so browser-based clients can round-trip it.No payloads leave the server
beforeSenddrops$mcp_parametersand$mcp_responseon every event.Neither side of a call is safe to capture here. Results are serialized through
jsonResponse, so a response reaches the SDK as one JSON string and its key-name redaction can't see inside it — a newly created API key, a TOTP code, or a CDP URL would go out verbatim. Arguments are free-form on many tools: credential field maps (manage_credentialsvalues,manage_auth_connectionsfields),browser_curlheaders and body,computer_actiontyped text,exec_commandcommands,execute_playwright_codesource. An allow-list of "credential tools" would always be one tool behind, so the payloads go entirely and what remains is call metadata.If we later want an argument breakdown (e.g. which
actionis most used), the safe shape is an explicit allow-list of individual parameter keys.Deliberately left off
instrument()can inject a requiredcontextargument into every tool schema to capture what the agent was trying to do ($mcp_intent). It's the most interesting signal, but it changes a public tool surface — a required field plus a 15-25 word instruction on all 17 toolsets — so this ships withcontext: false. Worth turning on as its own change once the basic numbers are landing.The SDK is pre-1.0 (
0.x), so event names and properties can change in minor releases — hence the exact-version pin.Verification
bunx tsc --noEmitpasses (what CI runs).tools/listand atools/callreplaying the minted header land as three events under one session id — the exact id inside the token — each carrying client name and version. A control request that doesn't replay the header lands in its own session with a null client, which is what the currently deployed staging server produces for every event.$mcp_parametersnor$mcp_responsepresent, and no canary string anywhere in the event.bun run buildfails locally on this branch and onmainalike (page data collection for/authorizewithout Clerk/Redis env), so that failure is unrelated.Note
$mcp_server_namecurrently readsmcp-typescript server on vercel— mcp-handler's defaultserverInfo, which is also what agents see. PassingserverInfo: { name: "kernel-mcp-server", version }tocreateMcpHandlerwould fix both, but it changes the advertised server identity so it's left out of this PR.Note
Medium Risk
Changes run on every authenticated MCP GET/POST and send telemetry externally, but capture is gated on env, strips sensitive payloads, and does not alter auth or tool schemas.
Overview
Adds PostHog MCP analytics so
initialize,tools/list, andtools/callemit$mcp_*events (tool name, latency, errors, client info) whenPOSTHOG_PROJECT_TOKENis set; without it, instrumentation is a no-op.New
src/lib/mcp/analytics.tswires@posthog/mcpwith metadata-only capture: a strict property allow-list strips tool arguments, responses, and error text; intent injection and exception autocapture stay off; person identification is disabled (identify: null).The MCP transport route instruments the handler, schedules
after(flushMcpAnalytics)so flushes don’t block responses, and on streamable HTTPinitializemints anMcp-Session-Idtoken (injected on the request and echoed on the response) so handshake and follow-up calls share one analytics session. CORS preflight/response headers allow clients to round-trip that header..env.exampledocumentsPOSTHOG_PROJECT_TOKENandPOSTHOG_HOST; dependencies add pinned@posthog/mcpandposthog-node.Reviewed by Cursor Bugbot for commit 96adad9. Bugbot is set up for automated code reviews on this repo. Configure here.